31 — 修改材质

Three.js Journey · Bruno Simon · 难度:简单

31 — 修改材质课程海报

简介

到目前为止,我们一直在创建全新的 shader 材质。但如果我们只是想修改 Three.js 内置材质之一呢?也许我们对 MeshStandardMaterial 的结果很满意,但想给它添加顶点动画。如果我们重写整个 MeshStandardMaterial,处理光照、环境贴图、基于物理的渲染、所有类型的 texture 等等,将会花费太多时间。

相反,我们将从 MeshStandardMaterial 出发,尝试把我们的代码集成到它的 shader 中。

有两种做法:

虽然第二种方案完全可以接受,但我们需要花费大量时间研究 Three.js 源代码,才能弄清楚如何正确设置一切。

所以,我们将使用第一种技术。我们仍然要花一些时间研究 Three.js 代码,但会容易得多。

在本课中,我们将让模型的顶点以一种有趣的方式扭曲,同时材质的所有基础功能仍然正常工作,比如 shadow、texture、normal map 等。

准备工作

我们将使用与 Realistic Model Render 课程相同的设置,但使用著名的 Lee Perry-Smith 头部模型。它只是一个流行的模型,只有一个 mesh 和逼真的 texture,应该很适合我们的扭曲动画。

加载和基础材质的设置都已完成。在加载模型之前,创建了一个带有 map 和 normalMap 的 MeshStandardMaterial。然后这个材质被用于模型唯一的 Mesh 上。这个 Mesh 最后被添加到 scene 中。

接下来大部分代码都与材质有关。

挂接材质

我们有了 MeshStandardMaterial,但我们想修改它的 shader。

要修改一个材质,我们首先需要访问它原始的 shader。为此,我们可以使用材质上的 onBeforeCompile 属性。

如果我们给它分配一个函数,这个函数会在编译之前被调用,并传入 shader 选项作为第一个参数:

material.onBeforeCompile = (shader) =>
{
    console.log(shader)
}

现在我们就能访问 vertexShader、fragmentShader 以及 uniforms,我们可以修改它们并查看结果。

向 vertex shader 添加内容

如果你查看 vertexShader,会发现代码并不多。Three.js 使用自己的系统来包含 shader 片段,以防止在不同材质之间重复相同的代码。每个 #include ... 都会注入位于 Three.js 依赖特定文件夹中的代码。

在某种程度上,这对我们是合适的,因为我们可以用一个简单的原生 JavaScript replace(...) 来替换那些部分。

问题是我们不知道哪个部分在做什么,哪一个该替换。要理解代码,我们需要深入研究 three 依赖。

前往 /node_modules/three/src/renderers/shaders/ 文件夹。你会在那里找到大部分 Three.js shader 代码。

所有被包含的部分都称为 chunk,你可以在 ShaderChunk/ 文件夹中找到它们。

如果我们查看不同的 chunk,会发现 begin_vertex 似乎首先处理位置,它创建了一个名为 transformed 的变量。

让我们在此基础上构建代码。首先,我们需要替换这部分代码。因为这个 chunk 名为 begin_vertex,我们需要替换 #include <begin_vertex>:

material.onBeforeCompile = (shader) =>
{
    shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', '')
}

这应该会破坏材质,因为我们用空内容替换了之前的代码。我们的代码会有好几行,所以我们将使用反引号。

首先,把 include ... 放回去,不要破坏任何东西:

material.onBeforeCompile = (shader) =>
{
    shader.vertexShader = shader.vertexShader.replace(
        '#include <begin_vertex>',
        `
            #include <begin_vertex>
        `
    )
}

这段代码没有用,因为我们用相同的东西替换了相同的东西,但现在我们可以在 include 之后添加自己的代码。

举个例子,让我们把头部沿 y 轴移动。我们在 /node_modules/three/src/renderers/shaders/ShaderChunks/begin_vertex.glsl.js 文件中看到,创建了一个 transformed 变量,它应该包含顶点的位置。要用它的 y 属性来移动所有顶点:

shader.vertexShader = shader.vertexShader.replace(
    '#include <begin_vertex>',
    `
        #include <begin_vertex>

        transformed.y += 3.0;
    `
)

如你所见,它起作用了,但 shadow 似乎有问题。我们稍后会修复它。

移除 transformed.y += 3.0;。

扭曲

让我们来扭一扭……我们的顶点。实现扭曲效果有多种数学方法,这次我们将创建一个矩阵。

以下代码中只包含 GLSL 部分。

首先,我们将尝试以相同的角度旋转所有顶点。然后,我们会根据顶点的高度偏移这个角度,并让它动起来。

创建一个 angle 变量并赋予任意值:

#include <begin_vertex>

float angle = 0.3;

即使我们还没有移动顶点,你仍然可以刷新页面看看是否出现错误。

如果你还记得,矩阵就像一个管道,你向其中发送数据——比如一个 vector。这个管道会对该 vector 应用一个变换,然后输出结果。我们可以创建一个缩放 vector 的矩阵、一个旋转的矩阵、一个移动的矩阵,我们甚至可以组合它们。这些矩阵可以处理 2D 变换、3D 变换,甚至更多。

在我们的例子中,我们想做 2D 变换。确实,我们的顶点拥有 3D 坐标,但要做扭曲动画,我们只是围绕 x 和 z 轴旋转顶点,而不是沿 y 轴上下移动。

要创建 2D 旋转矩阵,我们不会深入数学细节。相反,我们将使用这个返回二维矩阵(mat2)的函数:

mat2 get2dRotateMatrix(float _angle)
{
    return mat2(cos(_angle), - sin(_angle), sin(_angle), cos(_angle));
}

如果你想了解更多相关内容,可以在 The Book of Shaders 上找到细节:https://thebookofshaders.com/08/

但我们应该把这个函数确切地添加到哪里呢?如果这是我们自己的 shader,我们会把它放在 main 函数之前,这正是我们要做的。位于 main 函数之外的一个 chunk 是 common。这个 chunk 的优点是它出现在所有 shader 中。让我们像处理 begin_vertex chunk 那样替换这个部分:

material.onBeforeCompile = (shader) =>
{
    shader.vertexShader = shader.vertexShader.replace(
        '#include <common>',
        `
            #include <common>
        `
    )

    // ...
}

现在我们可以添加我们的 get2dRotateMatrix:

material.onBeforeCompile = (shader) =>
{
    shader.vertexShader = shader.vertexShader.replace(
        '#include <common>',
        `
            #include <common>

            mat2 get2dRotateMatrix(float _angle)
            {
                return mat2(cos(_angle), - sin(_angle), sin(_angle), cos(_angle));
            }
        `
    )

    // ...
}

没有任何变化,但现在我们可以在 shader 中的任何地方访问 get2dRotateMatrix,就像在 begin_vertex chunk 中一样。这是因为所有 chunk 都被合并在一起,就好像它只是一段代码。

使用 get2dRotateMatrix 函数创建 rotateMatrix 变量:

#include <begin_vertex>

float angle = 0.3;
mat2 rotateMatrix = get2dRotateMatrix(angle);

现在我们有了一个名为 rotateMatrix 的矩阵,它应该能旋转一个 vec2。让我们把这个矩阵同时应用到 x 和 z 属性上:

#include <begin_vertex>

float angle = 0.3;
mat2 rotateMatrix = get2dRotateMatrix(angle);

transformed.xz = rotateMatrix * transformed.xz;

头部应该已经旋转了。同样,别在意 shadow;我们稍后会处理它。

我们几乎完成了扭曲旋转。我们只需要根据高度来改变 angle:

float angle = position.y * 0.9;

可怜的脑袋。是时候让角度动起来了。

动画

我们将使用与之前相同的技术,向 shader 发送一个 uTime uniform。我们已经可以通过 shader.uniforms 访问 uniforms。让我们像更新 ShaderMaterial 那样更新它:

material.onBeforeCompile = function(shader)
{
    shader.uniforms.uTime = { value: 0 }

    // ...
}

现在我们可以在这个 common chunk 中获取我们的 uTime uniform:

#include <common>

uniform float uTime;

mat2 get2dRotateMatrix(float _angle)
{
    return mat2(cos(_angle), - sin(_angle), sin(_angle), cos(_angle));
}

并在 begin_vertex chunk 中使用它:

#include <begin_vertex>

float angle = (position.y + uTime) * 0.9;
mat2 rotateMatrix = get2dRotateMatrix(angle);

transformed.xz = rotateMatrix * transformed.xz;

你应该会得到相同的结果,因为我们还没有让 uTime 的值动起来。不幸的是,我们遇到了一个 JavaScript 问题。我们似乎没有办法在 tick 函数中访问这个 uniform。与 ShaderMaterial 不同,我们不能直接访问 material 的 uniforms,这是由于 Three.js 的结构所致。

有很多方法可以解决这个问题。我们需要的只是访问那些 uniforms。让我们在材质之前创建一个 customUniforms 对象,并把我们的 uTime 放进去:

const customUniforms = {
    uTime: { value: 0 }
}

然后,我们在 onBeforeCompile 函数中使用这个对象:

material.onBeforeCompile = (shader) =>
{
    shader.uniforms.uTime = customUniforms.uTime

    // ...
}

而且因为我们的 customUniforms 声明在任何作用域之外,我们可以在 tick 函数中用 elapsedTime 变量简单地更新它:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update material
    customUniforms.uTime.value = elapsedTime

    // ...
}
lesson demo video preview

现在,头部开始旋转了。

让我们修复 shadow。

修复 shadow

正如我们在前面的课程中看到的,使用 shadow 时,Three.js 会从光源的视角对 scene 进行渲染。这些渲染会产生显示阴影或光照范围内物体的图像。当这些渲染发生时,所有材质都会被替换为专用于该次渲染的另一组材质。问题在于,这种材质不会扭曲,因为它与我们修改过的 MeshStandardMaterial 没有任何关系。

如果我们直接在头部后面添加一个平面,就能看到这一点:

/**
 * Plane
 */
const plane = new THREE.Mesh(
    new THREE.PlaneGeometry(15, 15, 15),
    new THREE.MeshStandardMaterial()
)
plane.rotation.y = Math.PI
plane.position.y = - 5
plane.position.z = 5
scene.add(plane)
lesson demo video preview

我们需要找到一种方法让那个材质也扭曲。

用于 shadow 的材质是 MeshDepthMaterial,我们无法轻易访问该材质,但我们可以通过 mesh 上的 customDepthMaterial 属性来覆盖它,从而告诉 Three.js 使用一个自定义材质。

首先,让我们创建这个自定义材质。我们将使用 MeshDepthMaterial,因为这正是 Three.js 在这些渲染期间所使用的。我们把它命名为 depthMaterial,并向它的 depthPacking 属性添加 THREE.RGBADepthPacking:

const depthMaterial = new THREE.MeshDepthMaterial({
    depthPacking: THREE.RGBADepthPacking
})

我们这里不会详细解释 depthPacking 是什么,但它只是一种通过分别使用 r、g、b 和 a 来存储深度以获得更好精度的方式,Three.js 需要它。

要应用我们的自定义深度材质,在模型加载后,我们用自己创建的 depthMaterial 更改 customDepthMaterial 属性:

gltfLoader.load(
    '/models/LeePerrySmith/LeePerrySmith.glb',
    (gltf) =>
    {
        // ...

        mesh.material = material // Update the material
        mesh.customDepthMaterial = depthMaterial // Update the depth material

        // ...
    }
)

这里,我们是在模型中唯一的 mesh 上做的,但如果有一个包含多个 mesh 的更复杂的模型,我们就必须遍历它并更新所有材质。

现在我们可以把我们之前对 shader 所做的所有更改应用到 depthMaterial 上。

复制并粘贴 onBeforeCompile 部分:

depthMaterial.onBeforeCompile = (shader) =>
{
    shader.uniforms.uTime = customUniforms.uTime
    shader.vertexShader = shader.vertexShader.replace(
        '#include <common>',
        `
            #include <common>

            uniform float uTime;

            mat2 get2dRotateMatrix(float _angle)
            {
                return mat2(cos(_angle), - sin(_angle), sin(_angle), cos(_angle));
            }
        `
    )
    shader.vertexShader = shader.vertexShader.replace(
        '#include <begin_vertex>',
        `
            #include <begin_vertex>

            float angle = (position.y + uTime) * 0.9;
            mat2 rotateMatrix = get2dRotateMatrix(angle);

            transformed.xz = rotateMatrix * transformed.xz;
        `
    )
}

如果你查看平面上的 shadow,你也应该能看到扭曲。但我们仍然有一个问题。投影阴影(drop shadow)没问题,但模型上的核心阴影(core shadow)似乎不对,看起来 shadow 随着顶点一起旋转了。

lesson demo video preview

这是一个与 normals 相关的问题。

修复 normals

在前面的课程中,我们看到 normals 是与每个顶点相关联的坐标,用于指示面朝向的方向。如果我们能看到这些 normals,它们会是遍布整个模型、指向外部的箭头。这些 normals 用于光照、反射和 shadow 等。

当我们旋转顶点时,我们只是旋转了位置,但没有旋转 normals。我们需要修改处理 normals 的 chunk。

稍等;这部分有点棘手。

首先处理 normals 的 chunk 叫做 beginnormal_vertex。让我们为 material 替换它——而不是 depthMaterial,因为后者不需要 normals:

material.onBeforeCompile = (shader) =>
{
    // ...

    shader.vertexShader = shader.vertexShader.replace(
        '#include <beginnormal_vertex>',
        `
            #include <beginnormal_vertex>
        `
    )

    // ...
}

如果你查看位于 /node_modules/three/src/renderers/shaders/ShaderChunks/beginnormal_vertex.glsl.js 的 chunk,你会看到 normal 变量名是 objectNormal。我们可能会想对 transformed 变量做同样的处理:

#include <beginnormal_vertex>

float angle = (position.y + 4.0) * sin(uTime) * 0.9;
mat2 rotateMatrix = get2dRotateMatrix(angle);

objectNormal.xz = rotateMatrix * objectNormal.xz;

不幸的是,这会导致 shader 错误,包含以下消息:'angle' : redefinition 和 'rotateMatrix' : redefinition。

发生这种情况是因为我们忘了所有这些 shader chunk 最终会被合并成一个唯一的 shader。我们添加到 beginnormal_vertex chunk 的代码会与我们添加到 begin_vertex 的代码并排存在,而我们不能有两个同名的变量声明。

我们需要移除重复的声明。如果你查看初始的 vertexShader,你会看到 beginnormal_vertex 出现在 begin_vertex 之前。这意味着我们应该从 begin_vertex 中移除 angle 和 rotateMatrix:

material.onBeforeCompile = function(shader)
{
    // ...

    shader.vertexShader = shader.vertexShader.replace(
        '#include <beginnormal_vertex>',
        `
            #include <beginnormal_vertex>

            float angle = (position.y + uTime) * 0.9;
            mat2 rotateMatrix = get2dRotateMatrix(angle);

            objectNormal.xz = rotateMatrix * objectNormal.xz;
        `
    )
    shader.vertexShader = shader.vertexShader.replace(
        '#include <begin_vertex>',
        `
            #include <begin_vertex>

            transformed.xz = rotateMatrix * transformed.xz;
        `
    )
}
lesson demo video preview

现在一切应该都能正常工作了,我们的 begin_vertex 正在使用来自 beginnormal_vertex chunk 的 angle 和 rotateMatrix。

进一步探索

本课到此结束,但如果你愿意,可以走得更远。

你可以用调试面板甚至鼠标来控制扭曲。

你可以尝试其他动画。例如,这个 angle 的公式看起来更令人不安——请确保同时修改 material 和 depthMaterial:

float angle = (sin(position.y + uTime)) * 0.4;
lesson demo video preview

你还可以改进我们处理 GLSL 代码的方式。拥有专门的文件可能会很方便。